iT邦幫忙

2025 iThome 鐵人賽

DAY 8
2
Modern Web

Angular 進階實務 30天系列 第 8

Day 8:跨域解決與服務分層架構

  • 分享至 

  • xImage
  •  

前言

如果你看到主控台出現 .「... CORS error…」這個錯誤, 他是來自於 同源政策(Same-Origin Policy) 的關係,這個政策是瀏覽器內建的安全機制,顧名思義就是只允許同來源的請求。如果你從不同域名、協議或埠口的伺服器發起資源請求,它就會把你擋下來。

通常是開發階段才會需要前端來處理,如果是到測試環境跟正式環境,就要由後端設定CORS的允許網址,或是在 同源主機 上部署,或是在 devOps 的時候設定 反向代理。

而這篇會提供在開發環境時候的設定跟測試、開發時的nginx範例。

說個題外話,雖然多學一點是一點,也會常聽到就多幫一點忙,但我覺得還是要區分清楚工作範圍跟權責,不是單純的收下一句「能者多勞」,如果要你部署跟調整設定的話,你會的就不只是前端的工作,而是你執行了前端跟devOps的工作,你也學會了前端跟devOps,談薪的時候就能明確的知道自己的價值。

環境 解決方案 前端設定位置 後端/代理設定位置 說明
開發 Angular CLI 代理 proxy.conf.json 無需額外設定 開發伺服器處理代理
測試/正式 後端 CORS 無需額外設定 後端應用程式伺服器 API 直接允許跨域
測試/正式 Nginx 反向代理 無需額外設定 Web 伺服器 (Nginx) 代理層統一處理
測試/正式 Docker 反向代理 無需額外設定 容器編排設定 容器間網路處理

瀏覽器如何知道不符合同源政策

瀏覽器是在發送請求「之前」就知道是否跨域

  1. 請求發出前:瀏覽器檢查 URL 是否同源
  2. 如果跨域 + 複雜請求:先發送 OPTIONS 預檢
  3. 預檢通過:才發送真實請求
  4. 預檢失敗:直接阻止,不發送真實請求
URL 1 URL 2 是否同源 原因
http://localhost:4200 http://localhost:4200/api 路徑不影響同源判斷
http://localhost:4200 http://localhost:4200/api/users 路徑不影響同源判斷
http://localhost:4200 http://localhost:3000 port 不同 (4200 vs 3000)
http://localhost:4200 https://localhost:4200 protocol 不同 (http vs https)
http://localhost:4200 http://127.0.0.1:4200 host 不同 (localhost vs 127.0.0.1)
http://localhost http://localhost:80 HTTP 預設 port 80
https://localhost https://localhost:443 HTTPS 預設 port 443
http://example.com http://www.example.com subdomain 不同

開發環境 Angular Proxy設定

在開發環境的時候可以這樣設定,請在跟angular.js同層的位置放入proxy.json,也要記得在 angular.json裡面告訴專案啟動的時候要使用proxy.json這個檔案。

我就沒有一一列出所有方法囉!以下是參考概念

proxy.conf.json

*// proxy.conf.json*
{
  "/api/*": {
    "target": "http://localhost:3000", // 根據後端實際提供url設定
    "secure": false, //此處設定跟http有關,http是false,https是true
  }
}

專案資料夾結構(建議位置)

my-angular-project/
├── src/
│   ├── app/
│   ├── assets/
│   └── index.html
├── angular.json                  ← 與這個同一層
├── package.json                  ← 與這個同一層
├── proxy.conf.json              ← 放在這裡
├── tsconfig.json
└── node_modules/

angular.json

{
    "projects": {
        "your-app-name": {
            "architect": {
                "serve": {
                    "builder": "@angular-devkit/build-angular:dev-server",
                    "options": {
                        "proxyConfig": "proxy.conf.json"   ← 放在這裡,在開發環境使用
                    }
                }
            }
        }
    }
}

測試/正式環境部署

這裡提供nginx.conf的設定,理論上把讀取編譯檔的路徑換掉就好,就是這段 [ root {檔案路徑}/dist/{檔案路徑} ] ,大概類似像這樣 root C:/Users/dora/Documents/itHelp/dist/front;

這份檔案要放到你電腦中的nginx資料夾中,端看你電腦的OS種類位置會有不同,可以再問問GPT或是留言跟我討論。

# nginx.conf - Angular + API 反向代理配置

# 全局設置
#user  nobody;
worker_processes  1;        # 工作進程數量,通常設為 CPU 核心數

events {
    worker_connections  1024;   # 每個工作進程的最大連接數
}

http {
    # 基本設置
    include       mime.types;           # 包含 MIME 類型定義
    default_type  application/octet-stream;
    sendfile        on;                 # 高效文件傳輸
    keepalive_timeout  65;              # 連接保持時間

    server {
        listen 80;                      # 監聽 80 端口
        server_name localhost;          # 伺服器名稱
        
        # Angular 打包後的靜態文件路徑
        root {檔案路徑}/dist/{檔案路徑};
        index index.html;
        
        # 啟用 Gzip 壓縮
        gzip on;
        gzip_types text/css application/javascript application/json image/svg+xml;
        
        # API 反向代理 - 解決跨域問題的關鍵
        location /api/ {
            proxy_pass http://localhost:3000;      # 轉發到後端 API
            proxy_http_version 1.1;
            proxy_set_header Host $host;           # 保持原始 Host
            proxy_set_header X-Real-IP $remote_addr;       # 客戶端真實 IP
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;    # 原始協議
            proxy_set_header Upgrade $http_upgrade;        # WebSocket 支援
            proxy_set_header Connection 'upgrade';
            proxy_cache_bypass $http_upgrade;
        }
        
        # 靜態資源快取設置
        location ~* \.(js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|otf)$ {
            expires 1y;                            # 快取 1 年
            add_header Cache-Control "public, immutable";
            try_files $uri =404;                   # 資源不存在時返回 404
        }
        
        # Angular SPA 路由支援
        location / {
            try_files $uri $uri/ /index.html;      # 找不到文件時返回 index.html
        }
        
        # 錯誤頁面設置
        error_page 404 /404.html;
        error_page 500 502 503 504 /50x.html;
        
        # 安全 Headers
        add_header X-Frame-Options "SAMEORIGIN" always;     # 防止點擊劫持
        add_header X-Content-Type-Options "nosniff" always; # 防止 MIME 嗅探
        add_header X-XSS-Protection "1; mode=block" always; # XSS 防護
        
        # 隱藏 Nginx 版本資訊
        server_tokens off;
    }
}

反向代理的原理

反向代理其實就是轉接、轉寄、轉發的動作,以寫信舉例的話,這間公司不允許你寄信給國外公司,所以你寄給國內代理公司,國內代理公司就會再幫你轉寄給國外。

你 → 寫信給「國內代理地址」→ 代理公司 → 轉寄到國外朋友
│                           │              │
│    看起來是國內信件         │     實際國際轉寄
│                           │              │
公司政策:「國內信件,OK!」   隱藏的轉寄過程   真正的收件人

反向代理的架構圖如下,從代理伺服器代理到API伺服器上

瀏覽器請求 http://localhost:80
          ↓
    代理伺服器 (port 80)
    │ 實現方式:Node.js 或 Nginx │
      ↙            ↘
靜態檔案         API代理到
(Angular)     localhost:3000

其實我每次都覺得反向代理的反向兩個字很難理解。

但應該跟歷史比較有關,一開始習慣是客戶端到伺服器,所以從伺服器到客戶端,就被稱為反向了。

正向代理 vs 反向代理

正向代理(Forward Proxy)

  • 代表客戶端向伺服器發送請求
  • 伺服器不知道真正的客戶端身份
  • 流向:客戶端 → 正向代理 → 伺服器
  • 例如:公司防火牆、VPN

反向代理(Reverse Proxy)

  • 代表伺服接收客戶端請求
  • 客戶端不知道真正的伺服器身份
  • 流向:客戶端 → 反向代理 → 後端伺服器
  • 例如:Nginx、CDN、負載均衡器

服務層級架構

處理完同源政策問題之後,就可以開始串接API了,中小型的專案通常會分層三層進行。

http-base.service → user.service → user.component

  • httpBase統一處理:URL轉換、錯誤處理
// 1. HTTP基礎服務 - http-base.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';

export interface ApiResponse<T> {
  data: T;
  message: string;
  status: number;
}

@Injectable({
  providedIn: 'root'
})
export class HttpBaseService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = 'https://api.example.com/v1';

  protected get<T>(endpoint: string): Observable<T> {
    return this.http.get<ApiResponse<T>>(this.buildUrl(endpoint))
      .pipe(
        map(response => response.data),
        catchError(this.handleError)
      );
  }

  protected post<T>(endpoint: string, data: any): Observable<T> {
    return this.http.post<ApiResponse<T>>(this.buildUrl(endpoint), data)
      .pipe(
        map(response => response.data),
        catchError(this.handleError)
      );
  }

  protected put<T>(endpoint: string, data: any): Observable<T> {
    return this.http.put<ApiResponse<T>>(this.buildUrl(endpoint), data)
      .pipe(
        map(response => response.data),
        catchError(this.handleError)
      );
  }

  protected delete<T>(endpoint: string): Observable<T> {
    return this.http.delete<ApiResponse<T>>(this.buildUrl(endpoint))
      .pipe(
        map(response => response.data),
        catchError(this.handleError)
      );
  }

  private buildUrl(endpoint: string): string {
    return `${this.baseUrl}/${endpoint.replace(/^\//, '')}`;
  }

  private handleError(error: HttpErrorResponse): Observable<never> {
    let errorMessage = '發生未知錯誤';
    
    if (error.error instanceof ErrorEvent) {
      // 客戶端錯誤
      errorMessage = `錯誤:${error.error.message}`;
    } else {
      // 服務端錯誤
      switch (error.status) {
        case 400:
          errorMessage = '請求參數錯誤';
          break;
        case 401:
          errorMessage = '未授權,請重新登入';
          break;
        case 403:
          errorMessage = '權限不足';
          break;
        case 404:
          errorMessage = '資源不存在';
          break;
        case 500:
          errorMessage = '伺服器內部錯誤';
          break;
        default:
          errorMessage = `錯誤代碼:${error.status}`;
      }
    }

    console.error('HTTP錯誤:', error);
    return throwError(() => new Error(errorMessage));
  }
}
  • 功能service封裝:業務邏輯抽象
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpBaseService } from './http-base.service';

export interface User {
  id: number;
  name: string;
  email: string;
  phone?: string;
  avatar?: string;
  createdAt: string;
}

export interface CreateUserRequest {
  name: string;
  email: string;
  phone?: string;
}

export interface UpdateUserRequest extends Partial<CreateUserRequest> {
  avatar?: string;
}

@Injectable({
  providedIn: 'root'
})
export class UserService extends HttpBaseService {
  private readonly endpoint = 'users';

  // 獲取用戶列表
  getUsers(): Observable<User[]> {
    return this.get<User[]>(this.endpoint);
  }
...
}
  • 元件使用
@Component({
  selector: 'app-day8',
  standalone: true,
  imports: [
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    ...
  ],
  templateUrl: './day8.component.html',
  styleUrl: './day8.component.scss'
})
export class Day8Component {
  private readonly userService = inject(UserService);
  private readonly fb = inject(FormBuilder);
  private readonly message = inject(NzMessageService);

  // 響應式狀態
  users = signal<User[]>([]);
  loading = signal(false);
  error = signal<string | null>(null);

  // 計算屬性
  userCount = computed(() => this.users().length);

  // 響應式表單
  userForm: FormGroup = this.fb.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    phone: ['']
  });

  constructor() {
    this.loadUsers();
  }

  // 載入用戶列表
  loadUsers(): void {
    this.loading.set(true);
    this.error.set(null);

    this.userService.getUsers().subscribe({
      next: (users) => {
        this.users.set(users);
        this.loading.set(false);
        this.message.success(`成功載入 ${users.length} 位用戶`);
      },
      error: (error) => {
        this.error.set(error.message);
        this.loading.set(false);
        this.message.error('載入用戶失敗');
      }
    });
  }
  ...
}


上一篇
Day 7:Angular HTTP服務設計:從API規範到架構選擇
下一篇
Day9:新增、修改跟刪除
系列文
Angular 進階實務 30天21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言